Skip to content

fix(acp,core,relay): publish agent profiles on kind:10100 and move channel-add policy to 10101 - #4994

Open
jongouveia wants to merge 3 commits into
block:mainfrom
jongouveia:feat/acp-agent-profile-10100
Open

fix(acp,core,relay): publish agent profiles on kind:10100 and move channel-add policy to 10101#4994
jongouveia wants to merge 3 commits into
block:mainfrom
jongouveia:feat/acp-agent-profile-10100

Conversation

@jongouveia

Copy link
Copy Markdown

Problem

Kind 10100 is doing two incompatible jobs, and because it is replaceable (one event per author) the two overwrite each other.

As an agent profile. crates/buzz-core/src/kind.rs documents KIND_AGENT_PROFILE = 10100 as "Agent metadata + owner reference (replaceable, agent-authored)", and three consumers read it that way:

  • desktop/src-tauri/src/nostr_convert.rsagents_from_events
  • desktop/src-tauri/src/commands/agent_discovery.rs — queries kinds: [10100]
  • the mobile app — nostr_filters.dart (agentProfiles()), agent_identity_provider.dart (AgentDirectoryEntry.fromEvent)

As a channel-add policy. buzz channels set-add-policy publishes kind:10100 with {"channel_add_policy": ...}, and the relay's side effect requires that field, rejecting anything without it:

kind:10100 missing channel_add_policy field

Nothing ever published the agent profile side, so the conflict stayed dormant. The consequence is visible on mobile: a running agent never appears in the directory, and because the presence cache only tracks pubkeys the app has discovered, it also never shows as online.

Changes

1. buzz-acp publishes the agent profile (kind:10100).

Published over the authenticated POST /events bridge once channel discovery completes, and republished when membership changes so channel_ids stays current. Content carries the fields consumers already parse: name, display_name, agent_type, channels, channel_ids, capabilities, status, respond_to, respond_to_allowlist.

The name is resolved once at startup from the agent's own kind:0 display_name, falling back to the normalized runtime command when no kind:0 profile exists. Without that, every agent would advertise its runtime ("codex-acp", "claude-agent-acp") instead of its name. Publishing is gated on the existing presence flag and a failure only warns.

2. The channel-add policy moves to its own kind (10101).

KIND_CHANNEL_ADD_POLICY = 10101 is registered in ALL_KINDS with the usual replaceable compile-time assertion. The relay accepts it under the same UsersWrite scope and routes it to the policy handler, renamed from handle_agent_profile to handle_channel_add_policy since that is all it ever did. The CLI publishes the new kind.

Back-compat runs both ways: a kind:10100 that still carries channel_add_policy is honored for older clients, and one without it is a no-op instead of an error.

No migration. The enforced policy lives in the users table and is written by the side effect, so existing rows stay valid.

The desktop and mobile clients need no changes — they already read 10100 as the agent profile.

Testing

just ci passes on this branch merged with main (exit 0).

New coverage:

  • agent_profile_event_contains_mobile_directory_fields — event shape, kind, author, sorted channel and allowlist arrays
  • agent_profile_event_falls_back_to_runtime_identity_without_kind0_profile — name fallback
  • channel_add_policy_kind_is_registered_and_replaceable
  • channel_add_policy_is_global_only_and_requires_users_write
  • legacy_agent_profile_channel_add_policy_still_applies_policy (Postgres-gated)
  • agent_profile_without_channel_add_policy_is_a_no_op (Postgres-gated)

The two Postgres-gated tests were not exercised locally; they need just test with Postgres and Redis.

Manual check: running the built harness against a live relay logs agent profile published after discovered N channel(s), and the relay accepts the event.

jongouveia and others added 3 commits August 3, 2026 22:47
Clients build their agent directory from kind:10100 agent-profile events,
but nothing published them. The desktop app publishes only a kind:0 profile
for a managed agent, and channel-level bot membership (kind:39002) is not a
source those directories read, so a running agent never appeared in the list
and could not be tracked for presence.

buzz-acp now publishes a signed kind:10100 profile over the authenticated
POST /events bridge once channel discovery finishes, and republishes it when
membership changes so channel_ids stays current. Content carries the name,
respond_to mode, and allowlist that consumers use for mention eligibility.
The name comes from the agent's own kind:0 display name, falling back to the
normalized runtime command when no profile exists. Publishing is gated on the
existing presence flag, and a failure only warns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUks9koR8d9L3gC9ARsSLy
Signed-off-by: Jon Gouveia <jon@pixelcove.co>
Kind 10100 was doing two jobs. The registry documents it as the agent
profile, and the desktop and mobile clients both read it that way, but the
relay also used it to carry a user's channel_add_policy and rejected any
10100 without that field. Since 10100 is replaceable, an agent profile and a
policy silently overwrote each other.

Adds KIND_CHANNEL_ADD_POLICY (10101) for the policy and leaves agent profiles
on 10100. The relay accepts the new kind with the same UsersWrite scope and
routes it to the policy handler, renamed to say what it does. Older clients
still work: a 10100 carrying channel_add_policy is honored, and one without it
is now a no-op instead of an error. The CLI publishes the new kind.

No migration. The enforced policy lives in the users table and is written by
the side effect, so existing rows stay valid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUks9koR8d9L3gC9ARsSLy
Signed-off-by: Jon Gouveia <jon@pixelcove.co>
Signed-off-by: Jon Gouveia <jon@pixelcove.co>
@Glucksberg

Copy link
Copy Markdown

We're running exactly the topology this PR targets — three headless buzz-acp agents under systemd on a self-hosted relay — and hit the gap it fixes: the agents were fully functional (presence 20001 online, replying in channels) but invisible to Desktop's relay-agent directory until we manually published kind:10100 events for them.

Two field notes from that workaround that seem relevant to the current diff:

  1. Durable status:"online" goes stale. Our manually published 10100 profiles carry status:"online" forever — Desktop showed the agents online even with their services stopped. The diff also writes "status": "online" into the profile. Since liveness already has a correct home (20001 heartbeat + 180s TTL + offline on graceful shutdown), could the durable profile omit status entirely and let surfaces overlay live presence (the direction fix(desktop): report agent presence from the relay, not the profile default #4537 takes for Pulse)? Otherwise every crash leaves a lying directory entry that nothing ever corrects.

  2. Profile republish gated on presence_enabled. The membership-change republish path sits inside config.presence_enabled. An operator running --no-presence presumably still wants the agent discoverable and mentionable — the directory entry is identity/config, not liveness. Decoupling them keeps --no-presence meaning "no liveness signal" rather than "invisible".

Happy to test this branch against our production setup (self-hosted relay + 3 systemd headless agents + Desktop clients) and report back — including the 10100→10101 policy migration path, since our relay currently holds both manual 10100 profiles and set-add-policy writes that would collide.

@Glucksberg

Copy link
Copy Markdown

Went back through the current diff. One correction to my own comment above, then three notes.

Correcting myself on status

I suggested the durable profile omit status and let surfaces overlay live presence. That would break the desktop directory as it stands:

  • agents_from_events fills in "status": "offline" when the field is absent — desktop/src-tauri/src/nostr_convert.rs:479.
  • relayStatusToManagedStatus maps "offline""stopped" and everything else → "deployed"desktop/src/features/channels/ui/useChannelAgentSessions.ts:48.

So omitting the field doesn't defer to presence, it pins every agent to "stopped". Writing "online" at startup is right, and under the desktop's own mapping the field reads as deployed, not live.

The staleness point survives in a smaller and more concrete form: nothing ever writes the field back. The shutdown path already republishes presence as offline behind a 2s timeout —

// Best-effort: set presence to offline before exiting.
if config.presence_enabled {
    match tokio::time::timeout(
        Duration::from_secs(2),
        publish_presence(&presence_publisher, &presence_keys, "offline"),
    )

— and a symmetric publish_agent_profile(…, status: "offline") in that same block would cover the ordinary systemctl stop/restart case, which is what our fleet actually does day to day. A hard crash would still leave a stale entry; that one genuinely needs live presence overlaid at the surface, and that belongs outside this PR.

Relays older than this PR log the new profile at error level

Worth knowing for self-hosted deployments, which upgrade the relay and the agents separately.

On current main the 10100 side effect requires channel_add_policy and returns Err without it. Side effects run after the insert, and the error is logged rather than surfaced (crates/buzz-relay/src/handlers/ingest.rs:2955):

// error!, not warn!: the event was accepted but its side effects
// (channel creation, git repo seeding, …) did not run …
// Production runs RUST_LOG=error, so warn! made these failures invisible …
error!(event_id = %event_id_hex, kind = kind_u32, "Side effect failed: {e}");

So an agent built from this branch, pointed at a relay that isn't, still publishes a perfectly good profile — the event is stored, and Desktop and mobile read it fine. The only consequence is one error! line per publish, plus one per membership-change republish:

Side effect failed: kind:10100 missing channel_add_policy field

Not a blocker and not something this PR can fix on the relay side — the affected relays are already released. But since RUST_LOG=error is the production level precisely so these lines are visible, it's worth a sentence in the PR description saying relays should be updated before agents.

The merge conflict is one import block

git merge-tree upstream/main <this branch> conflicts in exactly one file, crates/buzz-relay/src/handlers/ingest.rs, and only inside use buzz_core::kind::{…}. main added KIND_PRIVATE_MANAGED_AGENT while this branch added KIND_CHANNEL_ADD_POLICY; both sides then rustfmt-rewrapped the whole list, so the textual hunks overlap even though the change is additive. Keep both names and let cargo fmt rewrap. Everything else, including the 248 lines in crates/buzz-acp/src/lib.rs, auto-merges clean.

Still open from my earlier comment

The presence_enabled gate. It reads a bit stronger now that I've been through the consumers: this profile is the only thing that puts an agent in the relay directory, and agentAutocompleteEligibility.ts uses respond_to/respond_to_allowlist from it to decide @-mention eligibility. So --no-presence currently means "invisible and un-mentionable", not "no liveness signal". Decoupling the profile publish from the flag is a one-line change if you agree with the reading.


Happy to run the buzz-acp half against our fleet — three headless agents under systemd on a self-hosted relay — once the conflict is off the branch. That deployment is itself the mixed-version case from the second note, since the relay predates 10101, so I can confirm both the directory result and the log behaviour empirically.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants